iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 11

推薦路線(2)——能走的路很多,該選哪一條?

  • 分享至 

  • xImage
  •  

昨天已經把散步時間、距離與偏好,轉成 Google Routes 能理解的起點、終點和 waypoint。

但只算出一條能走的道路還不夠。

假設使用者設定:

  • 目標距離:3 公里
  • 偏好:公園
  • 回到起點:是

Google Routes 可能算出 900 公尺,也可能超過四公里。這些路線都能走,卻不一定符合使用者真正想要的散步體驗。

因此不能只建立一組 waypoint,而是要先產生多組候選,再從中挑出最符合條件的一條。

候選路線從哪裡來?

目前候選分成兩種:

  1. Places 候選
  2. 幾何候選

為什麼一定要兩種?

如果完全依賴 Places,在住宅區、郊區或使用者沒有設定偏好時,可能根本沒有足夠的候選,因此仍需要幾何候選當作保底。

Places 候選

昨天介紹過如何取得 waypoint,今天重點不是怎麼搜尋 Places,而是怎麼從搜尋結果挑出適合建立候選路線的 waypoint。

如果使用者選擇公園、咖啡店等偏好,就先透過 Google Places 搜尋起點附近符合條件的地點,再將合適的結果轉成 waypoint:

使用者選擇「公園」
        ↓
搜尋起點附近的公園
        ↓
取得地點座標
        ↓
建立 Places 候選

但搜尋到的第一個地點不一定適合。

如果使用者想走3公里並回到起點,而某座公園距離起點已經有3公里,光是來回就可能超過6公里。

因此,在回到起點的情況下,會先估算 waypoint 與起點的理想距離:

// 預期 waypoint 距離 = 目標路線距離 ÷ 2
const expectedPlaceDistance = targetDistance / (request.returnToStart ? 2 : 1);

如果目標是三公里環線,就會優先考慮距離起點約 1.5 公里的地點。

接著計算每個 Places 結果與起點的直線距離,再按照與預期距離的差距排序:

const relevantPlaces = places
  .map(place => ({
    place,
    distance: approximateDistance(
      request.start,
      place.location,
    ),
  }))
  .sort(
    (a, b) =>
      Math.abs(a.distance - expectedPlaceDistance) -
      Math.abs(b.distance - expectedPlaceDistance),
  )
  .slice(0, 2);

例如預期距離是 1500 公尺:

公園 A:300 公尺,差距 1200 公尺
公園 B:1400 公尺,差距 100 公尺
公園 C:2500 公尺,差距 1000 公尺

這時會優先保留公園 B,而不是選離起點最近的公園 A。

目前只取前兩個結果,避免產生太多候選,也能控制 Google Routes 的請求次數。

最後再把保留下來的地點轉成 waypoint,建立 placeCandidates

這裡比較的仍是直線距離,實際道路可能受到河流、圍牆、街道方向與人行道影響,最後仍要以 Google Routes 回傳的結果為準。

幾何候選

Places 不一定每次都有結果,使用者也可能沒有設定地點偏好。

因此,我另外根據起點、目標距離與方向,自行建立幾組幾何 waypoint。

以回到起點的路線為例,每組候選包含兩個轉折點:

起點
 ↓
轉折點一
 ↓
轉折點二
 ↓
起點

兩個 waypoint 已經足以讓路線產生轉折。加入太多 waypoint,反而可能讓路線為了經過指定座標而繞遠路。

const geometricCandidates =
  CANDIDATE_BEARINGS.map((bearing, index) => {
    const side = targetDistance / 3;

    const first = destinationPoint(
      request.start,
      side,
      bearing,
    );

    const second = destinationPoint(
      request.start,
      side,
      bearing + 60,
    );

    return [
      createWaypoint(first, index, 0),
      createWaypoint(second, index, 1),
    ];
  });

每個方向會建立一組候選,第二個 waypoint 再偏轉 60 度,避免整條路線只沿著同一個方向前進。

最後把兩種候選合併:

return [
  ...placeCandidates,
  ...geometricCandidates,
];

即使 Places 沒有找到合適地點,仍然可以使用幾何候選繼續規劃。

排除無法使用的候選

候選建立完成後,每一組 waypoint 都會分別交給 Google Routes 計算。

但幾何座標可能落在無法步行抵達的位置,也可能因為道路配置而找不到合理路線。

假設結果是:

候選 A:失敗
候選 B:成功
候選 C:成功
候選 D:失敗

B 和 C 仍然可以繼續使用,不需要因為其中一組失敗,就中止整次推薦。

因此,這裡使用 Promise.allSettled()

const results = await Promise.allSettled(
  candidates.map(candidate =>
    computeRoute(candidate),
  ),
);

const routes = results.flatMap(result =>
  result.status === 'fulfilled'
    ? result.value
    : [],
);

最後只讓成功的路線進入評分:

候選 A:失敗 ── 排除
候選 B:成功 ─┐
            ├→ 進入評分
候選 C:成功 ─┘
候選 D:失敗 ── 排除

怎麼比較候選路線?

經過前面的流程後,通常還會留下多條可以使用的路線。

例如:

候選 距離 時間 經過公園 重複路段
A 2.2 km 30 分鐘 10%
B 3.0 km 42 分鐘 5%
C 2.7 km 35 分鐘 8%

A 符合偏好,但距離偏短、B 距離最接近,卻沒有經過公園、C 沒有任何一項特別突出,但整體可能更符合需求。

Google Routes 只負責規劃道路,不會告訴我哪一條比較適合,因此還需要自己建立一套比較規則。

目前主要會考慮四個因素:

  • 距離是否接近目標
  • 時間是否接近目標
  • 是否符合使用者偏好
  • 是否有太多重複路段

距離與時間

距離和時間都不能直接比較相差幾公尺或幾分鐘。

例如同樣多出 500 公尺:

目標 1 公里,實際 1.5 公里
誤差 50%

目標 10 公里,實際 10.5 公里
誤差 5%

兩者都多走 500 公尺,但對一公里散步來說影響很大,對十公里則小得多。

因此,我改用相對誤差來比較不同長度的散步需求。

相對誤差 = |實際值 - 目標值| ÷ 目標值

另外,如果使用者直接指定公里數,我會提高距離的重要性;如果只指定散步時間,距離只是依平均步行速度換算出的參考值,因此權重就不需要那麼高。

偏好與重複路段

偏好目前不是重新分析整條道路沿途經過哪些 POI,而是直接根據建立候選時使用的 waypoint 判斷。

例如 waypoint 來自公園,而使用者也選擇公園偏好,就代表這條路線一定會經過公園,因此可以直接視為符合偏好。

至於重複路段,我沒有比對道路 ID,而是根據 Polyline 比較相鄰線段的位置與方向,估算整條路線有多少比例屬於原路折返。

雖然這種方法只是近似值,但實作相對簡單,也足以避免大量折返的路線被優先推薦。

綜合比較

最後,把前面幾個因素整理成一個分數,再比較所有候選路線。

分數越低代表越符合使用者需求:

const PREFERENCE_WEIGHT = 0.08;
const REPETITION_WEIGHT = 1.5;

// 距離誤差
const distanceScore =
  distanceWeight *
  Math.abs(route.distanceMeters - targetDistance) /
  Math.max(targetDistance, 1);

// 時間誤差
const durationScore =
  Math.abs(route.durationSeconds - targetDuration) /
  Math.max(targetDuration, 1);

// 符合多少偏好
const preferenceBonus =
  route.preferenceMatches.length *
  PREFERENCE_WEIGHT;

// 重複路段懲罰
const repetitionPenalty =
  preferLessRepetition
    ? route.repetitionRatio *
      REPETITION_WEIGHT
    : 0;

// score = 距離誤差 + 時間誤差 - 偏好獎勵 + 重複懲罰
const score = distanceScore + durationScore - preferenceBonus + repetitionPenalty;

距離和時間與目標差得越多,分數就會越高;符合的偏好越多,則會降低分數;如果有大量重複路段,就再加上額外的懲罰分數。

小結

這個計算方式與權重只是暫時的,後續還可以依照實際測試逐步調整。

最後比較所有候選的分數,分數最低的一條就會作為最終推薦路線回傳給 App。

到這裡,推薦路線的產生流程就完成了:

Places 候選 ─┐
             ├→ Google Routes
幾何候選 ────┘
                    ↓
              排除失敗路線
                    ↓
               計算候選分數
                    ↓
              選出最佳候選
                    ↓
                 回傳 App

接下來就可以把推薦結果顯示在地圖上,讓使用者真正開始沿著推薦路線散步。


上一篇
推薦路線(1)——根據散步需求生成路線
下一篇
推薦路線(3)——判斷是否可以開始散步
系列文
30 天開發一款真正能每天使用的散步 App12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言